#!/usr/bin/env python3.9
# Usage:
# tail -f /var/log/aprstraffic.log | ./aprsdistcurses.py <args>
#
# --dump: dump all records after the input is exhausted.
# --dump-only: no curses fanciness at all, just dump records
# --waitexit: just hang at the end (..for taking screenshots..)
# --longonly: remove the shortest-distance panel
# --pace <float>: updating rate throttle; seconds per message
# --dopos: includes stationary station position coordinates in dump
# --gpxout <file.gpx>: useful with Viking
#
# Ctrl-C/SIGINT'ing will do a controlled shutdown, as typing 'reset' from
# the terminal being left in raw mode is a pain..
#
# You may want to update the pos[], tally[] and finalstation
# initalization.
#
# As-is, the input is assumed to be the output from kissutil, but the
# regex should be fine with normal TNC2MON formatted frames.
#
# A local station is using "BEACON" as a destination, which makes aprspy
# skip decoding its position -- I've munge aprspy's __init__.py with
# s/BEACON/BACON/ to hack around it..
#
# apt-get/pip install install aprspy & geopy. gpxpy is optional.
#
# --
# Chris Baird,, vk2cjb@gmail.com, March-April 2022

from geopy import distance
import sys, re, time, curses, aprspy, signal, os
aprspy.logger.setLevel("ERROR") # stop that module spewing to stdout

# it helps acquistion to put in a few fixed stations, and to resolve
# any chance DX where any intermediate digis might not have been heard
# before. now you know what --dopos is for and why it cares only for
# stationary stations.
pos = {
    "VK1RGI-1" : (-35.526662, 148.769478),
    "VK2AMW-1" : (-34.5535, 150.629333),
    "VK2BWI-1" : (-33.699167, 151.055167),
    "VK2CJB-1" : (-32.912333, 151.312333),
    "VK2EVB-1" : (-30.280667, 153.124167),
    "VK2HUB-5" : (-33.84, 151.1085),
    "VK2RAB-1" : (-31.587833, 150.992667),
    "VK2RAG-1" : (-33.361167, 151.291),
    "VK2RCC-1" : (-31.271667, 149.061667),
    "VK2RHR-1" : (-34.370667, 150.3355),
    "VK2RMI-1" : (-29.807667, 150.278833),
    "VK2RMY-1" : (-30.813833, 152.9195),
    "VK2RNW-1" : (-30.278833, 150.1525),
    "VK2RPM-1" : (-31.701833, 152.678333),
    "VK2RTZ-1" : (-32.941, 151.515),
    "VK2UNI-1" : (-31.921667, 152.182167),
    "VK2XSO-1" : (-31.277333, 151.85),
    "VK2XSO-3" : (-30.008, 151.693833),
    "VK2ZSI-1" : (-30.9855, 150.243833),
    "VK3RGR-1" : (-36.438333, 146.248333),
    "VK3RHO-1" : (-36.975, 147.128),
    "VK3RNU-1" : (-36.451333, 146.803),
    "VK4RCR-3" : (-28.387833, 151.921667)
}
digipos = pos.copy()
pos["TERMINUS"] = (-32.912333, 151.312333) # location of this receiver
tally = { "VK2RTZ-1" : 19.220 } # bootstrap the nearest repeater

tallyfar, tallyclose, tallyall, stations, allpaths = {}, {}, {}, [], []
digis, pace, dump, longonly, waitexit = [], 0.0, 0, False, False
gpxout, seenpaths, dopos, direct, msgcount = "", [], False, [], {}
trafheight, trafwidth = 4+2, 17 # raw and live traffic panels
traf = [""]*trafheight

# ----------------------------------------------------------------------

def printlist(l):
    if len(l) > 0:
        c = int(screenwidth / len(l[0]))
        r = int(len(l) // c) + 1
        p, q = 0, 1

        for t in range(len(l)):
            print (l[p], end='')
            p += r
            if p >= len(l):
                print ()
                (p, q) = (q, q+1)


def printstationlist(l):
    if len(l) > 0:
        s = []
        for i in l:
            s.append(padcall(i))
        printlist(s)


def underline(s):
    s = s.rstrip().lstrip()
    print ("\n" + s + "\n" + "="*len(s))


def orderedinsert(l, e):
    if e in l:
        l.remove(e)
    l.insert(0, e)
    return l


def padcall(s):
    return (s + " "*11)[:11]


def cursesoff():
    curses.nocbreak()
    stdscr.keypad(0)
    curses.echo()
    curses.endwin()


def cursesquit(sign, frame):
    global waitexit
    waitexit = False

# ----------------------------------------------------------------------
# this would be at the end, but Python can't into forward declarations
#
def signal_handler(sign, frame):
    signal.signal(signal.SIGINT, cursesquit)

    if dump < 2:
        redraw()

    while waitexit:
        time.sleep(0.1)

    if dump < 2:
        cursesoff()

    # ----------------------------------------

    if dump > 0 and len(tallyall) > 0:
        missing, moving, stationary = [], [], []

        for i in sorted(stations):
            if not i in moving:
                stationary.append(i)
            if not i in pos:
                missing.append(i)

        underline("USED PATHS")
        for i in sorted(tallyall):
            print (padcall(i), end="")
            l = tallyall[i]
            p = {}
            for j in range(len(l)):
                dist, path = l[j]
                d = "{:.1f}".format(dist)
                path = str(path.split()[1])
                if path in p:
                    p[path] += " " + d
                else:
                    p[path] = d
            sp = ""
            for j in p:
                s = []
                for k in sorted(p[j].split(), key=float):
                    if float(k) not in s:
                        s.append(float(k))
                s = sorted(s, key=float, reverse=False)
                t = "[" + str(s[0])
                if len(s) > 1:
                    t += '-' + str(s[-1]) + "]"
                    # accept some jitter in stationary status
                    if s[-1] - s[0] >= 1.60934:
                        moving = orderedinsert(moving, i)
                else:
                    t += "]"
                print (sp, j, t)
                sp = " "*11

        # ----------------------------------------

        underline("LONGEST PATHS")
        s = []
        for i in sorted(tallyfar, key=tallyfar.get, reverse=True):
            d, r = tallyfar[i]
            s.append("{:6.1f}".format(d)+" "+padcall(i)+" "+r.split(" ")[1])
        printlist(s)

        if not longonly:
            underline("SHORTEST PATHS")
            s = []
            for i in sorted(tallyclose, key=tallyclose.get, reverse=False):
                d, r = tallyclose[i]
                s.append("{:6.1f}".format(d)+" "+padcall(i)+" "+r.split(" ")[1])
            printlist(s)

        underline("STATIONS HEARD")
        printstationlist(sorted(stations))

        if len(direct) > 0:
            underline("HEARD DIRECTLY")
            printstationlist(sorted(direct))

        if len(digis) > 0:
            underline("LIKELY DIGIPEATERS")
            printstationlist(sorted(digis))

        if len(moving) > 0:
            underline("STATIONARY STATIONS")
            printstationlist(sorted(stationary))
            underline("MOBILE STATIONS")
            printstationlist(sorted(moving))

        if len(missing) > 0:
            underline("MISSING LOCATION")
            printstationlist(sorted(missing))

        if len(msgcount) > 0:
            underline("MESSAGE COUNTS")
            s = []
            for i in sorted(msgcount, key=msgcount.get, reverse=True):
                t = "{:>5d} {:<11s}".format(msgcount[i], i)
                s.append(t)
            printlist(s)

        if dopos == True:
            underline("NON-MOBILE COORDINATES")
            for i in sorted(stationary):
                if i in pos:
                    print (i, str(pos[i]))

        # ----------------------------------------
        # todo - for some reason some digis aren't appearing

        underline("DIGIPEATER AFFINITY")
        hops, linkpaths = {}, []
        for i in allpaths:
            s,d = i
            if d == "TERMINUS":
                continue
            if not s in hops:
                hops[s] = (d,)
            else:
                hops[s] = hops[s] + (d,)

        for i in sorted(hops):
            f = {}
            for j in sorted(hops[i]):
                if not j in f:
                    f[j] = 0
                f[j] += 1

            l = len(hops[i])
            if i in digis:
                print (padcall(i + "*"), end=' ')
            else:
                 print (padcall(i), end=' ')
            for j in sorted(f, key=f.get, reverse=True):
                p = round(100.0*f[j]/l,1)
                t = " {:4.1f}".format(p)
                print (j + t[:5], end='  ')
                linkpaths.append((i,j,p))
            print ("({})".format(l))

    # ----------------------------------------
    # doesn't require --dump be used
    #
    if len(gpxout) > 0:
        d = time.strftime("%Y%m%d-%H%M")
        gpx = gpxpy.gpx.GPX()
        gpx.name = "APRS" + d
        gpx.description = "APRS network results for " + d

        # make sure all known digipeaters are included
        for i in digipos:
            if not i in stationary:
                stationary.append(i)

        w = []
        for (start,end,percent) in linkpaths:
            #if start in pos and end in pos and start in stationary and end in stationary:
            if start in pos and end in pos and start in digis and end in digis:
                track = gpxpy.gpx.GPXTrack()
                gpx.tracks.append(track)
                segment = gpxpy.gpx.GPXTrackSegment()
                track.segments.append(segment)
                segment.points.append(gpxpy.gpx.GPXTrackPoint(
                    pos[start][0], pos[start][1]))
                segment.points.append(gpxpy.gpx.GPXTrackPoint(
                    pos[end][0], pos[end][1]))
                if not start in w:
                    w.append(start)
                if not end in w:
                    w.append(end)

        for i in w:
            wp = gpxpy.gpx.GPXWaypoint()
            wp.symbol = "tower"
            (wp.latitude, wp.longitude) = pos[i]
            wp.name = i
            gpx.waypoints.append(wp)

        outfile = open(gpxout, "w")
        outfile.write(gpx.to_xml())
        outfile.close()

    sys.exit(0)

# ----------------------------------------------------------------------

while len(sys.argv) > 1:
    sys.argv.pop(0)
    s = sys.argv[0]
    if s == "--dump":
        dump = 1
    elif s == "--dumponly":
        dump = 2
    elif s == "--waitexit":
        waitexit = True
    elif s == "--longonly":
        longonly = True
    elif s == "--dopos":
        dopos = True
    elif s == "--pace":
        pace = float(sys.argv[1])
        sys.argv.pop(0)
    elif s == "--gpxout":
        gpxout = sys.argv[1]
        sys.argv.pop(0)
        try:
            import gpxpy
            import gpxpy.gpx
        except ImportError:
            print ("gpxpy needs installing")
            sys.exit(1)
    else:
        print ("\nyou wanted instructions? i only put this here to catch misspelled args")
        sys.exit(1)

# ----------------------------------------------------------------------

original_sigint_handler = signal.signal(signal.SIGINT, signal_handler)

if dump < 2:
    stdscr = curses.initscr()
    curses.noecho()
    curses.cbreak()
    curses.curs_set(0)
    (screenheight,screenwidth) = stdscr.getmaxyx()

    panelheight = screenheight - trafheight
    panelwidth = screenwidth - trafwidth
    winlatest = curses.newwin(panelheight, trafwidth, 0, 0)
    wintraf = curses.newwin(trafheight,screenwidth,screenheight-trafheight+1,0)
    if longonly:
        winfar = curses.newwin(panelheight, panelwidth, 0, trafwidth)
    else:
        winclose = curses.newwin(panelheight//2-1,panelwidth,panelheight//2+1,trafwidth)
        winfar = curses.newwin(panelheight//2, panelwidth, 0, trafwidth)
else:
    screenwidth = 80

# ----------------------------------------------------------------------

def redraw():
    # screen corruption is rare, but annoying when it happens, and there's
    # no Ctrl-L refresh handling because stdin is the pipe..
    if (time.time() - redraw.lastclear) >= 60:
        stdscr.clear()
        redraw.lastclear = time.time()

    for i in range(trafheight):
        wintraf.addstr(i, 0, traf[i])
        wintraf.clrtoeol()
        wintraf.refresh()

    for y in range(0, panelheight):
        s = " "
        if y < len(stations):
            s = " "*7 + stations[y]
            if stations[y] in tally:
                s = "{:6.1f} ".format(tally[stations[y]]) + stations[y]
        winlatest.addstr(y, 0, s)
        winlatest.clrtoeol()
        winlatest.refresh()

    t = 0
    for i in sorted(tallyfar, key=tallyfar.get, reverse=True):
        d, r = tallyfar[i]
        s = "{:6.1f}".format(d) + " " + r
        winfar.addstr(t, 0, s[:panelwidth-1])
        winfar.clrtoeol()
        t += 1
        if t == (panelheight if longonly else panelheight//2):
            break
        winfar.refresh()

    if not longonly:
        t = 0
        for i in sorted(tallyclose, key=tallyclose.get, reverse=False):
            d, r = tallyclose[i]
            s = "{:6.1f}".format(d) + " " + r
            winclose.addstr(t, 0, s[:panelwidth-1])
            winclose.clrtoeol()
            t += 1
            if t == panelheight//2-1:
                break
            winclose.refresh()

    stdscr.refresh()

redraw.lastclear = 0.0

# ----------------------------------------------------------------------

while True:
    if dump < 2:
        time.sleep(pace)
        redraw()

    aprsmsg = sys.stdin.readline()
    if aprsmsg == "":
        os.kill(os.getpid(), signal.SIGINT)

    aprsmsg = aprsmsg.encode('ascii',errors='ignore').decode().rstrip()
    aprsmsg = re.sub("^\[[0-9: ]+\] ", "", aprsmsg) # remove kissutil log cruft
    if not re.search("^(\d?[a-z]{1,3}|[a-z]\d[a-z]?)\d[a-z]{1,3}(\-1?[1-9]+)?>", \
                     aprsmsg, re.IGNORECASE): # see if there's a callsign
        continue

    for i in range(trafheight-3):
        traf[i] = traf[i+1]
        # lowercase the callsign if this msg doesn't provide location info
    traf[trafheight-3] = aprsmsg[0:7].lower() + aprsmsg[7:screenwidth]

    try:
        # aprspy borks when there's no path?
        # "VK2RTZ-1>APNU19:!3256.46SS15130.90E#PHG4430/W2"
        f = aprspy.APRS.parse(aprsmsg)
    except:
        continue

    s = f.source
    stations = orderedinsert (stations, s)
    if not s in msgcount:
        msgcount[s] = 0
    msgcount[s] += 1

    # not parsed by (unmodified) aprspy
    # VK2HMP-9>BEACON,VK2RTZ-1*,WIDE2-2:!3316.17S/15127.25Ej012/039/A=000159
    # VK2RTZ-1>APNU19,NOGATE:;146.775- *111111z3256.  S/15130.  Er R50k<0x20>

    try:
        if hasattr(f, 'latitude'):
            coord = (f.latitude, f.longitude)
            pos[f.source] = (coord)
        else:
            continue
    except:
        continue

    traf[trafheight-3] = aprsmsg[:screenwidth] # now indicate there's location info

    lastcoord = pos[s]
    laststation = s
    dist = 0
    path = str(f.path) + ",TERMINUS"
    hadpath = False
    for r in path.split(','):
        if "WIDE" in r or "RFONLY" in r or "NOGATE" in r:
            continue
        d = r.rstrip('*')
        if d in pos and laststation in pos:
            seenpaths.append((laststation,d))
        if d != "TERMINUS":
            allpaths.append((laststation,d))
            hadpath = True
            if not d in digis:
                digis.append(d)
        if d in pos:
            coord = pos[d]
            dist += distance.distance(coord, lastcoord).km
            lastcoord = coord
        else:
            dist = -100000000
        laststation = d

    if not hadpath and not s in direct:
        direct.append(s)

    seenpaths = list(set(seenpaths))

    if dist > 0 and dist < 5000:
        tally[s] = dist
        t = (dist, padcall(f.source) + str(f.path))

        if not s in tallyall:
            (tallyall[s], tallyfar[s], tallyclose[s]) = ([], t, t)
        tallyall[s] += [t]

        if dist > tallyfar[s][0]:
            tallyfar[s] = t

        if dist < tallyclose[s][0]:
            tallyclose[s] = t

# ----------------------------------------------------------------------
